Skip to content

refactor: migrate wait to request-bound runtime - #1875

Merged
thymikee merged 5 commits into
agent/wave4-getfrom
agent/wave4-wait
Aug 20, 2026
Merged

refactor: migrate wait to request-bound runtime#1875
thymikee merged 5 commits into
agent/wave4-getfrom
agent/wave4-wait

Conversation

@thymikee

@thymikee thymikee commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Migrates wait from legacy capability admission to the request-bound selector runtime, stacked on #1877.

The final ownership correction keeps one admission/binding seam while splitting its declarations by command intent:

  • is admits capture only.
  • get and read-only find admit capture plus the preferred readTextAtPoint operation.
  • wait admits capture plus the fact-conditional findText and findSelector observations.

An owner can therefore advertise a wait-only observation without making unrelated get, is, or find commands depend on its implementation. wait still fails closed when its owner advertises an observation without implementing it. Duration-only waits inspect and bind nothing; native misses fall through to the same admitted capture; recording, replay, landmarks, @ref, cancellation, and public response shaping retain the canonical paths.

The tightening pass removed the broad three-operation selector projection and its three pass-through bind helpers. The remaining intent-specific projections are the only construction paths, and the main snapshot binder was split before it crossed the complexity budget. No legacy/capability fallback or compatibility branch was retained.

The full stacked PR is 76 files, +2,507/-841. Excluding tests, docs, and scripts, it is approximately +1,026/-410; that growth is the request-bound runtime contract, owner facts/implementations, admission/binding, and the removal proof for the old execution path. This correction is 14 files, +282/-92 total and approximately +217/-76 production. The smaller shared-use design was rejected because it made commands depend on operations they never execute; the smaller untyped projection would allow that owner/use mismatch to recur.

CLI grammar, flags, help, response shape, and user-visible behavior are unchanged. Skills are unchanged because this is internal runtime ownership, not command guidance.

Validation

Exact stack:

  • refactor: migrate get to the request-bound device runtime #1877 base: 7d3e5e6e48a4ae5e2f41945a5b8d892c7573acff

  • this PR head: 8e6bb5a0b1696213d0db458527d4eb4825bd0858

  • Planted red: temporarily routing find, get, and is through the wait-observation use made all three new regressions fail with Runtime owner advertised findText without an implementation; the dedicated wait rejection remained green. Restoring the intent split made all cases green.

  • Focused runtime tests: 5 files, 38 tests passed.

  • pnpm typecheck: passed.

  • pnpm check:layering: 181/181 passed; every migrated command retains one platform-execution route.

  • pnpm check:fallow --base origin/main: no new dead code, duplication, or complexity findings.

  • pnpm check:affected --run && git push: all runnable checks passed on the committed tree. Affected coverage ran 649 files / 5,174 tests; changed-line coverage was 218/221 (98.64%) and changed-branch coverage was 181/195 (92.82%). Build, package, provider integration, wire compatibility, gate ownership, and mutation-model checks passed.

The exact-head GitHub and Size jobs were triggered by the push and are intentionally not awaited here. Their device/toolchain results remain GitHub-authoritative.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.33 MB 2.33 MB +4.1 kB
JS gzip 766.3 kB 767.4 kB +1.1 kB
npm tarball 891.0 kB 892.2 kB +1.3 kB
npm unpacked 3.10 MB 3.11 MB +4.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 22.2 ms 22.0 ms -0.2 ms
CLI --help 56.9 ms 55.0 ms -1.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-batch-runner.js +1.3 kB +325 B
dist/src/runtime4.js +1.3 kB +253 B
dist/src/runtime2.js +44 B +26 B
dist/src/internal/daemon.js +46 B +22 B
dist/src/runtime.js +44 B +7 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 20b2c33ff5ce95da33af7a65d6a207cbafbe4431: one blocking behavior regression. iOS Smoke proves the retired Apple findText arm is not semantically redundant: wait text "Last input: press" performs 17 readable canonical-tree captures, never observes the target, and times out with wait_target_absent. That is the precise Apple divergence currently described as residual risk, so it cannot be treated as covered parity or fixed by a rerun. ADR 0019 §6 requires deliberate behavior changes to be decided separately from a migration; §9 allows a measured preferred operation, and #1876’s selector-seam record classifies this path that way. Please preserve the successful runner answer through the request-bound runtime (with measurement and ADR 0011 classification), or land an explicitly accepted behavior narrowing first, and add a regression that fails when this target is tree-only-polled. Separately, macOS Smoke looks like retry/session-cleanup infrastructure (attempt-2 still owns the host at attempt-3 step 1) and needs a clean rerun. The draft also still owes its stated reconciliation with #1876’s published selector seam. Not ready-for-human at this head.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head CI: 28/28 green at 8f57e7d89

  • iOS Smoke: pass. This is the lane that caught the findText regression at the previous head
    (wait text "Last input: press" → 17 readable captures, wait_target_absent). Green on the
    restored preferred operation, so the divergence is closed on the platform that exhibited it.
  • macOS Smoke: pass on the clean rerun — confirming it was retry/session-cleanup
    infrastructure, not this change.
  • Android, Linux, Web Platform Smoke, Integration Tests, Coverage, Layering Guard, Fallow, Bundle
    Size, Packaged CLI, Swift Runner Host XCTests, Typecheck, Lint/Format, CodeQL, Maestro
    Conformance Oracle, Released-Surface Compatibility, Replay-Compat Provenance: pass.

Zero failures, zero reruns needed on this head. PR remains draft pending review.

@thymikee
thymikee marked this pull request as ready for review August 19, 2026 13:36
@thymikee

Copy link
Copy Markdown
Member Author

Out of draft at 8f57e7d89. All 28 required checks green — including iOS Smoke, the lane that caught the regression at 20b2c33ff. macOS Smoke also passed on the clean rerun, confirming it was retry/cleanup infrastructure rather than this change. Zero reruns needed.

Stack: #1875#1877#1876main.

Your blocking finding is closed, and the framing changed

You were right that the findText retirement was a real regression, not covered parity — wait text "Last input: press" doing 17 readable canonical-tree captures and timing out with wait_target_absent. findText is restored as wait's one measured preferred operation, bound through the request runtime rather than daemon-side. §9 measurement: 2.82× faster, ~95 ms saved per satisfied text wait (n=12 per arm, iPhone 16 Pro). The regression that guards it is red the way Smoke was red — planting the deletion back reproduces wait_target_absent with the full budget burned.

The investigation you asked for reframed the active-app question. This PR does not make wait refuse; it makes wait ask the facts, which yields refuse-on-iOS and proceed-on-Android automatically. On iOS, appBundleId is the XCUITest attach target — with none set, the runner's own process comes forward and displaces the app under test, then answers confidently about its own 3-node screen. Four consequences, owned rather than buried:

  • iOS no-app wait <text>: a 10 s timeout implying the text was absent becomes an immediate refusal naming open. Nothing true is lost.
  • iOS no-app wait stable and wait @ref: stop returning success. These were false successes about the runner's own screen — wait @ref was returning text: "AgentDeviceRunner". This is the one place a script flips from passing to failing, and it was already lying.
  • Android: zero change.
  • Cold path: up to ~28 s saved.

Qualifier stated as a qualifier, not as settled: the refusal originated in #681 to stop snapshot returning the runner's tree, but the rationale surviving in code — that such a capture "cannot succeed" — is too strong. #1296 live-validated capture against SpringBoard; the real constraint is that XCUITest needs some bundle id, not the user's app. Filed separately as #1881.

Also in this PR

  • Apple's decisions left the daemon. selector-runtime-backend.ts is −3,058 B; no family, provider, surface, or session conditional survives there. The conditions under which Apple cannot answer moved into packages/platform-apple.
  • snapshotPlanUnavailableResponse is exported here with its first consumer, giving the selector family one refusal-wording owner instead of three spellings.
  • No ADR 0011 matrix row, deliberately — the matrix classifies zero observation commands and its guarantee vocabulary is entirely tap semantics, so a wait row would be all-inapplicable. Reasoning recorded rather than a vacuous row shipped.

Gate notes

The first check:affected run failed with three real defects that a narrower gate would have missed: a provider transcript still scripting the retired findText path, the test-file-size ratchet catching a pinned file growing (fixed by compacting the file's own factory, not by raising the pin), and the interactor-runner-provider partition requiring the new Interactor method be classified. Second run clean at 5,040 tests.

One retraction: an earlier comment flagged check:production-exports as pre-existing-red on main. That was a stale dist — it needs the fresh build the gate performs first, and passes in sequence. Disregard it.

@thymikee

Copy link
Copy Markdown
Member Author

Two blockers at exact head 8f57e7d8:

  1. [P1] Preserve runner execution context for native text waits. selector-runtime-backend.ts calls bound findText with only text, options, and poll signal, while bindAppleFindTextRuntime builds its interactor context from input.execution. wait text therefore drops requestId, log/trace paths, XCUITest override paths, and runner lease context that the retired path forwarded. Build execution through the existing request/capture-context builder and add a regression asserting native findText receives the same runner context as capture.

  2. Size/design: CI reports +6.2 kB npm unpacked, above the heightened-review threshold. The 112-line wait-runtime-binding.ts duplicates admission/bind/projection already owned by the selector seam, and its paragraph-long safety rationales are evidence the boundary is wrong. Consolidate this behind the selector owning interface, then itemize any irreducible growth and why a smaller design was rejected.

All exact-head checks are green and the live iOS evidence restores the original text behavior, but neither covers the dropped configured-runner context.

thymikee pushed a commit that referenced this pull request Aug 19, 2026
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
thymikee pushed a commit that referenced this pull request Aug 19, 2026
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
@thymikee

Copy link
Copy Markdown
Member Author

Blocking: this head is DIRTY against current main, so rebase and validate the resolved exact head first; today’s green CI proves only 8f57e7d. More importantly, the cutover removes dispatchDirectIosSelectorWait/querySelector but adds only a text findText preferred operation. A normal non-recording iOS wait id=… or label=… that the runner finds while the canonical tree misses now times out where it previously succeeded. Put that native selector query behind an admitted owner-provided preferred runtime operation (or preserve equivalent semantics), then add the red regression where query finds while the tree does not plus live iOS evidence. Also itemize the >700 net production-line growth and why a smaller owning-operation design was rejected, and document the deliberate no-app wait behavior change. No ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

The predicted selector regression does not reproduce — evidence, and its limits

You asked for the red regression where the runner query finds while the tree does not, plus live iOS evidence. We went looking for that divergence first rather than building the operation, and across every shape tested on iPhone 16 Pro it does not exist. This is the "or preserve equivalent semantics" branch of your ask.

1. Pruned wrapper nodes (Settings root)

Raw 167 → canonical 73, 30 shapes pruned, including four Buttons carrying identifiers. But the addressable sets are identical:

labels in raw but ABSENT from canonical tree: 0
identifiers absent from canonical:            0

Group-pruning collapses duplicate Button-inside-Cell wrappers and the label/identifier survives on the collapsed parent. Live, tree-only, post-retirement:

wait 'id=com.apple.settings.siri' 4000    → {"waitedMs":121}
wait 'id=com.apple.settings.homeScreen'   → {"waitedMs":113}
wait 'label="Apple Intelligence & Siri"'  → {"waitedMs":112}

2. Deep, dense hierarchy (Accessibility) — your scenario if anywhere

Raw 148 → canonical 57, and here the addressable sets genuinely do diverge: 6 labels and 2 identifiers absent from canonical (KEYBOARDS, LIVE_SPEECH_TITLE, Live Speech, Keyboards & Typing, …). Differential test — is still carries the runner shortcut on this base, so it probes the runner directly:

id=KEYBOARDS            RUNNER: Selector did not match   TREE: wait timed out
id=LIVE_SPEECH_TITLE    RUNNER: Selector did not match   TREE: wait timed out
label="Live Speech"     RUNNER: Selector did not match   TREE: wait timed out

Validity check, because a shortcut that silently did not fire would fake this result: runner commands issued by is exists: ['querySelector', 'snapshot']. The runner was genuinely asked, refused, and fell through.

A transient mid-navigation artifact (Keyboards & Typing with zero-size rects at y:116) was ruled out by settling 3 s and re-testing — both runner and tree still refuse.

3. Why it does not diverge — mechanism, not sample

The canonical tree prunes for exactly two reasons, and the runner's behavior lines up with both:

  • duplicate wrapper collapse → the label/id survives on the survivor, so the selector still resolves;
  • off-viewport / virtualized content → the runner refuses these too, being conservative about visibility.

So the pruned set and the runner-resolvable set are complementary, not overlapping. That is a reason to expect non-divergence rather than a lucky sample. It is also consistent with a separate finding this wave: a hypothesis that the direct-iOS path returns pass: true for off-viewport nodes was traced through the code and then refuted on device, because XCUITest's own query refused the node the tree carried.

What was not tested

Stock Settings only — no third-party, React Native, or SwiftUI hierarchies, where pruning could bite differently. No --depth-limited waits, no occlusion, no horizontally off-screen drawer content.

So this is "did not reproduce across the shapes most likely to expose it", not "proved impossible".

What we are not doing, and why

No red regression is being added for this. A test asserting a divergence we cannot produce would be vacuous — green for the wrong reason, and exactly the class this wave has been rejecting elsewhere (the fictional R37 sentinels, the threading-only signal coverage). We would rather leave the claim unasserted than assert it falsely.

If you can name a concrete app and selector that diverges, we will test it immediately and build the admitted preferred operation if it holds. That is a cheap check and the offer is open.

thymikee pushed a commit that referenced this pull request Aug 20, 2026
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
thymikee pushed a commit that referenced this pull request Aug 20, 2026
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 062598f. The dropped findText execution context is fixed and the duplicate wait binder is gone; own growth is modest (+262 net production lines, +736 B npm unpacked). One contract blocker remains: findText is declared preferred, but the regression explicitly proves that removing it changes a successful wait into wait_target_absent because the target never appears in the canonical tree. ADR 0019 requires the required-only path to be semantically complete and permits preferred absence/failure to change optimization, not correctness. Model native text observation as correctness-bearing text-wait behavior through the owning runtime interface; the current preferred classification contradicts its own test evidence. Separately, this head is CONFLICTING/DIRTY against the updated #1877 base, so resolve/restack and rerun exact-head CI before re-review. No ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed on exact head b1685481094f2276f671c15a55e9c384befd8bca.

The selector-wait regression is now closed through an admitted owner-provided preferred operation, not a daemon exception. findSelector is declared in the selector capture use, admitted by Apple facts, projected from the single bound runtime, and called only after availability inspection. Positive native evidence satisfies the wait; a miss falls through to the same bound canonical capture.

Provider integration now proves both directions: native found: true succeeds with an intentionally empty tree, and native found: false consumes the scripted snapshot and succeeds through canonical resolution. Planting removal of the projection reproduces wait_target_absent. Recording/replay and no-app paths do not invoke the preferred operation. The Apple query command builder is shared with the remaining safety probe, so this adds no second query implementation.

The no-app change is explicit in the refreshed body: iOS refuses before binding; Android continues to capture the launcher according to Android facts.

Local exact-head gates: pnpm check (7,207 tests) and chained pnpm check:affected --run && git push (5,151 tests), 97.60% changed-line coverage, zero duplication. Exact-head size is +734 B gzip / +3.4 kB unpacked. All GitHub checks are green except iOS Smoke still running at the time of this comment.

@thymikee

Copy link
Copy Markdown
Member Author

CI handoff: every non-iOS check is green. iOS Smoke attempt 1 failed in the unrelated form-input scenario because the device produced ada@example.estt (transposed final keystrokes); no wait/selector assertion failed. Failed-job-only attempt 2 is currently running. Per request, I am not waiting for its verdict.

thymikee pushed a commit that referenced this pull request Aug 20, 2026
Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the exact-head blocker at 626ae94704b8892779b311d559d42ee3a324dbdc. findText and findSelector are no longer declared preferred: they are fact-conditional semantic operations. Advertised owner cells must bind concrete implementations and fail closed before bind if missing; unavailable cells retain the parity-proven complete capture path. The three runtime-use categories are pairwise disjoint and enforced through the descriptor, plan, admission, binding, and projection types. ADR 0019 now states that contract. The stack was rebased onto latest main (5bdc1554f) via #1877 at 4a1cf8520; pnpm check and the chained pnpm check:affected --run && git push passed. New exact-head GitHub/device lanes are running; per request, I am not waiting for CI.

@thymikee
thymikee marked this pull request as draft August 20, 2026 11:58
@thymikee

Copy link
Copy Markdown
Member Author

Not ready yet. The fact-conditional runtime shape now correctly preserves the owner-dependent selector observations, but this head is conflicting/dirty against the updated #1877 base, so restack it and validate the resolved exact head; iOS Smoke is also still pending. The Size run for this head reports +3.8 kB npm unpacked (+950 B gzip), above the review threshold. Please update the PR evidence with the exact resolved SHA and itemize the remaining packaged growth, including why the shared selector seam/conditional design cannot be made smaller. Then refresh the red/green and live evidence against that head.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at exact rebased head 1952bfdaacbb194a45ebbda0ccf1d609e8df214b on #1877 base 7d3e5e6e48a4ae5e2f41945a5b8d892c7573acff (latest main parity source 5bdc1554f256fab9f35fe50fbdd412f9463e27e9).

The stale/conflicting stack is resolved and force-pushed with a lease only after pnpm check:affected --run passed. I also tightened the packaged design before publication:

  • empty conditional metadata is no longer emitted into unrelated runtime declarations, removing the avoidable sdk-batch-runner growth;
  • the duplicate find-text-runtime and find-selector-runtime modules/fact constructors are consolidated into one selector-observation seam while the two owner facts remain independently advertised and typed;
  • that tightening is net -55 lines (+161/-216 across 34 files).

The PR body now itemizes the previous +3.8 kB unpacked / +950 B gzip report, what this head removes from it, and why the remaining conditional selector surface is correctness-bearing: making those operations preferred recreates the reviewed timeout bug, removing exact binding permits advertised-but-missing implementations, and moving the policy into the daemon violates provider ownership. There is one construction path and no legacy/capability fallback.

Red/green evidence is refreshed in the body. The final affected gate passed all runnable checks (649 files / 5,169 tests in affected coverage, 98.41% changed-line coverage, layering 181/181, duplication 0). The architecture-wide run had one unrelated artifact-cleanup flake under 7,264-test load; its entire file passed immediately in isolation (39/39).

The refreshed Size and device lanes, including iOS Smoke, are running at this exact head. Per instruction I am not waiting for CI and am not claiming their verdict; the PR remains draft.

@thymikee

Copy link
Copy Markdown
Member Author

[P1] Keep wait-only observations out of the shared selector use

selectorCaptureRuntimePlanUses is also the declared and bound use for get, is, and the read-only find leg, but only wait invokes findText or findSelector. If an owner advertises either conditional fact without its implementation, narrowDeviceBinding now rejects those unrelated commands before their complete capture path, even though they have no semantic dependency on the observation. This also makes R36/R37 claim wait-only conditionals without the parity evidence ADR 0019 requires.

Keep the shared selector binder, but give wait its own capture use/plan carrying these conditionals and retain observation-free selector uses for get, is, and read-only find. Please add a regression proving that an advertised-but-missing wait observation cannot break those commands capture-backed behavior.

@thymikee
thymikee marked this pull request as ready for review August 20, 2026 13:20
@thymikee

Copy link
Copy Markdown
Member Author

Addressed the final ownership blocker at exact head 8e6bb5a0b1696213d0db458527d4eb4825bd0858. The shared binder now selects three intent-specific uses: capture-only for is, element-text for get/read-only find, and conditional native observations only for wait. An advertised wait operation can no longer reject an unrelated command; wait still fails closed when that advertised implementation is missing. The tightening pass also removes the broad three-operation projection and its three pass-through bind helpers, and extracts the selector binding decision before the main binder crosses the complexity budget. Planted-red proof produced the expected three unrelated-command failures before restoring the split. The required pnpm check:affected --run && git push passed (649 files / 5,174 tests; changed-line coverage 98.64%; layering, Fallow, build, package, provider integration, and structural gates green). The PR body now records exact scope and growth rationale. GitHub CI was triggered and intentionally not awaited.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 20, 2026
@thymikee
thymikee merged commit 17bdca7 into main Aug 20, 2026
28 checks passed
@thymikee
thymikee deleted the agent/wave4-wait branch August 20, 2026 13:59
thymikee added a commit that referenced this pull request Aug 20, 2026
* refactor: migrate get to the request-bound device runtime

`get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred
`readTextAtPoint`), admits once from exact owner facts, refuses before binding,
and binds exactly once. Its capability bucket, the static HarmonyOS/Web command
sets that augmented it, and `requireCommandSupported` admission for `get` are
gone; `'get'` leaves the `createSelectorRuntime` capability union.

The neutral `readTextAtPoint` operation replaces the branch-per-family legacy
`read` dispatch on the `get` path. Every local family and both providers now
classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it
unavailable, which is behaviour-preserving because the legacy dispatch had no arm
for them and threw on every call before falling back.

R36 is the new parametrized cutover row.

* fix(get): admit before the direct-iOS fast path; close the element-read outcome

Review blockers on #1877.

1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before
   `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019
   requires resolve -> admit -> bind before anything in the request path
   operates, so admission now runs first for every target shape and the fast
   path is a fast path *within* an admitted request. Regression: an eligible
   direct selector cannot operate when facts refuse admission.

2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught
   any throw and fell back, assigning a typed diagnostic after an untyped
   failure. It now returns a closed `ElementTextReadOutcome`; fallback happens
   only for the contract's classified reasons; unexpected errors propagate.
   The reason union is derived from its runtime list so the two cannot drift,
   and an unhandled reason is a compile error at the consumer.

This retires the generic catch the start record promised.

* feat(daemon): land the selector capture seam with get as its first consumer

Takes ownership of the request-bound selector capture seam from #1876, which
cannot ship standalone: with find's cutover deferred it had no consuming
command (ADR 0019 §10) and was not dead-code clean (check:production-exports
19 -> 20). `get` is its first consumer, so it lands here.

Adopts find's handoff as given. The one shape change, approved by the
coordinator: the selector family gets its own capture uses carrying a PREFERRED
`readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff`
keep binding exactly what they bind today. The read is surfaced through the
existing arms of `bindSnapshotCaptureRuntime`, reusing the same
selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second
plan-to-operation dispatch.

`get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime`
and its test are deleted as superseded, and `'get'` leaves the
`createSelectorRuntime` capability union.

The legacy read adapter survives for `find <q> get text` and is selected by
which command constructed the runtime — never by failure, family, environment,
or flag — so `get` cannot reach it. It retires in find's cutover, where the
last consumer moves.

* refactor: retire the read dispatch alias across both selector consumers

Read-only `find` now constructs a BOUND selector backend, so `get text` and
`find <q> get text` execute the same bound `readTextAtPoint` instead of one
binding it and the other dispatching the legacy `read`. This moves find's READ
LEG only: find's descriptor stays LEGACY_PLATFORM_EXECUTION and it claims no
cutover row.

With no consumer left, the whole chain goes: the `read` registry entry and its
`dispatch: {}` projection, `DISPATCH_HANDLERS.read`, `handleReadCommand`,
`interaction-read-legacy-dispatch.ts`, and the duplicate platform reader
branches it carried. `read` was the only `dispatch-alias` descriptor, so that
catalog group goes too.

Deleting the registry entry drops 'read' from DescriptorDispatchCommandName,
which makes a surviving DISPATCH_HANDLERS.read a compile error rather than
something R36 has to police. R36 now claims the retirement it can prove.

`find.test.ts` is over the size tripwire, so its handler invocation is
extracted to find-handler-fixture.ts and the pin lowered 1237 -> 1221.

* refactor(daemon): apply the seam addendum after #1876 was re-scoped

Two edits, per find's ADDENDUM.md:

1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from
   #1876 as unconsumed; the selector capture path is genuinely its first
   consumer (a Web rect capture requests bounds explicitly), so it lands here
   under the same rule that moved the seam. `snapshot`/`diff` pass nothing.

2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput`
   has no such field on this stack — it moved to `wait` (#1875) with the
   regression that proves per-poll abort and quiescence. `get` captures once
   per resolution and never polls, so nothing here needs it. The seam test and
   fixture coverage for it moves with the contract rather than being kept
   against a field that no longer exists.

* refactor(get): retire the direct-iOS selector shortcut

`get` declares device-runtime, so its request path must reach the platform only
through operations R36 declares. `dispatchDirectIosSelectorGet` reached
`runAppleRunnerCommand` through a path the row declares no operation for;
admitting before a bypass is not executing through the seam, so the bypass is
removed rather than ordered after admission. Every target shape — including the
simple iOS `id=` selector — now resolves through the bound capture.

`queryDirectIosSelector` itself stays: `offscreen-target-probe.ts` still
consumes it and it remains single-copy. `dispatchDirectIosSelectorIs` belongs to
`is` (#1883). Two get-only helpers (`readDirectIosGetSelector`,
`buildDirectIosGetResult`) became unreachable and are deleted with the caller.

Declaring `querySelector` as a fact-admitted preferred operation was rejected on
duplication, not correctness: the offscreen probe takes a plain session and
cannot consume a bound operation, so it would ship the query twice until Wave 5
moves the probe — the deferred-duplication shape this PR was already overruled
for on the `read` alias. It returns as a declared, §9-measured operation in a
later unit that also moves the probe.

Cost, stated plainly: `get text id=…` loses its tree-capture skip on iOS. No
fallback was added and the latency is not recovered elsewhere. R36's
singularExecution claim is now what the code does rather than aspirational.

* refactor: ride the Interactor seam for the element read; drop the bespoke host

Two operations of the same class were reaching their mechanics two different
ways: `findText` rides `Interactor` via `localInteractors.resolve`, while
`readTextAtPoint` had its own host port. That is duplication of MECHANISM, so
the read now rides the same seam.

`Interactor` gains `readTextAtPoint?`, implemented on the Apple, Android and
Linux interactors where those mechanics already live.
`src/platform-runtime-element-text-host.ts` and its `elementText` host wiring
are deleted; the contract binds through the resolver exactly as the snapshot
runtime does.

Size honesty: this removes an 89-line module but the four readers still have to
exist, so they moved into the interactors rather than vanishing. Net production
change is ~4 lines, not ~89. The duplication of mechanism is what is actually
fixed; Wave 5/6 retires the seam for both operations together.

Also from the size investigation:
- `ElementTextRuntimeExecution` was byte-identical to `SnapshotRuntimeExecution`;
  removed and reused, as `find-text-runtime.ts` does.
- Removed a stranded, stale comment in `selector-capture-binding.ts` that still
  claimed a duplication this branch had already retired.
- `FrozenUnavailablePlatformRuntimeFacts` is derived from its input type rather
  than restated, removing a 14-line clone group my new cell had pushed over the
  detector threshold.

* refactor: migrate is to the request-bound device runtime (#1883)

* refactor: migrate is to the request-bound device runtime

`is` declares the shared selector capture use, admits once from exact owner
facts, refuses before binding, and binds exactly once. Its capability bucket,
the static HarmonyOS/Web command sets that augmented it, and
`requireCommandSupported` admission for `is` are gone; `'is'` leaves the
`createSelectorRuntime` capability union.

Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires
resolve -> admit -> bind before anything in a `device-runtime` command's request
path reaches the device, so that query becomes a fast path *within* an admitted
request rather than a way around exact-owner facts. The rule is documented once,
on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments
`get` and `is` were each carrying.

Declared behaviour change: `is` takes the active-app plan split, so the facts
decide per family. On iOS `appBundleId` is the XCUITest attach target — with no
tracked app the runner's own process comes to the foreground, displaces the app
under test, and the capture then answers confidently about the runner's own
blank screen. An iOS `is` on a session with no tracked app is now a typed
SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats
displacing-and-lying. Android captures the real launcher in that state and is
unchanged, which is what the platform facts already encoded.

The two Apple watchOS cells move from capability-admitted-then-runner-failure to
a typed unavailable refusal, the same classification snapshot, diff, and get
already landed.

R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime`
and its `requireCommandSupported` call, so `captureData` stays optional and
`captureSnapshotWithInteractor` stays: this unit is not the last selector unit.

* fix(is): a failing iOS assertion fails instead of exiting zero

Reverses part of #557, on thymikee's explicit instruction.

`is` is an assertion: the docs state it "exits non-zero on failure". The
direct-iOS fast path broke that contract — it reported a failed predicate as a
completed command, so on device

    $ agent-device is text id=… "Wrong Expected Text"
    Passed: is text          (exit 0)

because `{ok: true, pass: false}` reaches `isCliOutput`, which renders
"Passed: is <predicate>" without reading `pass`. A failing assertion reported as
success lets a replay run on past a broken state. Now:

    Error (COMMAND_FAILED): is text failed for selector id=…:
      expected="Wrong Expected Text" actual="Apple Account, …"   (exit 1)

The renderer needed no patch: a negative can no longer produce a success
envelope, so it is correct by construction.

Direction chosen deliberately. Making the two paths agree could have gone either
way, and "an agent asked a question and got an answer" is a real argument for the
other one. This follows the DOCUMENTED contract rather than merely the incumbent
behaviour, and the alternative is a far larger change: a zero-exit `is` would
alter every platform and path, break scripts that rely on it failing the shell,
and needs its own PR, docs, and probably a major version. It is also already how
`is hidden` and `is exists` behave end to end.

PASSING assertion, and that arm still answers with zero captures (pinned). Only
the negative falls through — what #557's own summary asked for, "preserving
snapshot fallback for misses", refusing fallback only for hard failures like
ambiguity. The fall-through was #557's own design, never armed: the `| null`
return and the caller's `if (!payload) return null;` guard were unreachable.
This makes that dead guard live.

Measured on iPhone 17 (median of 9, warm daemon): predicate holds 0.14s / 0
snapshots, unchanged; predicate fails 0.25s / 1 snapshot. ~+0.11s on failing
assertions only.

Correctness gain beyond the envelope: the fast path evaluates a ONE-NODE tree, so
`visible` cannot see the ancestor geometry a list row inherits and its negative
can be wrong. Falling through re-asks the real tree and can turn a spurious
negative into a pass.

The #557 pin moved with its reasoning at the pin site.

* fix(layering): let a cutover row state a data-only admission retirement

Review blocker on #1883: R37 claimed `legacyRetirement.routeNames:
['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT']`. Neither identifier has
ever existed. They satisfied the non-empty shape check while proving nothing —
the vacuous registry claim AGENTS.md warns about, and a green gate that would
stay green if the deletion were reverted.

The cause was the model, not the row. Every `LegacyRetirementClaim` form names
something that must NOT exist, which a row can always satisfy by inventing a
name. `is` retired no module, route, or dispatch projection because it had none:
its legacy admission was a capability bucket plus membership in two static
platform command sets, so its real retirement is a DATA deletion the model could
not express. Rather than patch around that with sentinels or a per-command
policy file — both forbidden by the playbook — this generalizes the model.

`staticCommandSets` names the sets themselves and is proven from both sides:
each must still be DECLARED in production source, and must no longer list the
command. A fictional set fails the first half; a skipped deletion fails the
second. That is what an identifier-shaped claim cannot state.

R37 now claims HARMONYOS_SUPPORTED_COMMANDS and WEB_QUERY_COMMANDS, which is the
deletion it actually performed.

Planted red, both halves, against the real gate:

  [R37 is-runtime-cutover] 2 violation(s):
    (is cutover row):1 — claims retired static command set
      'WEB_QUERY_COMMANDS_WITH_IS', which no production source declares
    (is cutover row):1 — claims retired static command set
      'HARMONYOS_IS_SUPPORT', which no production source declares

  [R37 is-runtime-cutover] 2 violation(s):
    src/core/capabilities.ts:59 — static command set WEB_QUERY_COMMANDS still
      admits is

so the exact claim that shipped is now rejected by name, and so is restoring the
membership it claims to have removed. Mechanism cases live with the other
planted-row tests; layering goes 177 -> 181.

* test(is): pin the exit-code guarantee independently of what answers the predicate

Prep for the Blocker 1 retirement, which deletes `buildDirectIosIsResult` — the
function the #557 reversal fixed. The reversal's guarantee must not evaporate
with it, so it gets a case that does not know how the daemon decided.

`is` is documented to "exit non-zero on failure". The reversal proved that at
the JSON envelope; nothing pinned it at the CLI boundary, which is where the
defect was actually visible (`Passed: is text`, exit 0). This asserts the CLI
contract directly: a `predicate_failed` response exits 1 and never renders as
passed.

It survives the retirement untouched, because it asserts the outcome rather than
the path. Planted red with the exact pre-#1739 envelope the shortcut produced
(`{ok: true, data: {pass: false}}`): `exitSpy.calls` is `[]` — no exit call at
all — so the case fails, which is the regression it exists to catch.

Unpushed on purpose: the restack will carry it into the retirement cycle.

* refactor(is): retire the direct-iOS selector shortcut

thymikee's ruling (option b). `is` declares `device-runtime`, so its request path
must reach the device only through the operations R37 declares. It did not: a
simple iOS `id=`/`label=` target was answered by a direct XCUITest querySelector
without any capture, ordered after admission but not executing through the seam.

This is not retired because it was wrong. `wait` hypothesized that the degenerate
one-node evaluation mis-answers `is visible` for off-viewport nodes, traced it
through the code convincingly, then tested it on device and it did not reproduce
— XCUITest's own query is conservative about visibility, so the degenerate
evaluation never gets the chance. It is retired because it was an undeclared,
unmeasured bypass that made R37's singularExecution claim false: the same class
of untruth as the sentinel retirement names fixed in the previous commit.

Declaring querySelector as a real operation instead was rejected for a concrete
reason: offscreen-target-probe.ts consumes queryDirectIosSelector with a plain
session and cannot take a bound operation, so declaring it now would ship it
twice until Wave 5 moves the probe — the deferred-duplication shape that got
get's read deferral overruled. It returns as a declared, fact-admitted,
section 9-measured operation in the unit that also moves the probe.

Retired: dispatchDirectIosSelectorIs, its call site, buildDirectIosIsResult, and
resolveDirectIosSelectorQuery — each had exactly one caller, all on this path —
plus the ResolvedDirectIosSelectorQuery type they orphaned and two imports.
queryDirectIosSelector itself stays: the offscreen probe still consumes it and it
remains single-copy.

Latency cost, stated plainly and not softened: a held predicate on a simple iOS
selector goes from ~0.14s with no capture to ~0.25s with one, measured as the
median of 9 warm runs on iPhone 17. There is no fallback and no fast path.

R37's comment finally describes the code: "every predicate answers from the
resolved tree" was written while the shortcut existed. Its scope is now stated
too, so it is not read as absolute — the Android foreground-blocker diagnostic
still reaches adb on the failure path, where it cannot produce or change a
verdict; that edge is pre-existing, co-owned with wait, and recorded as Wave 6
denominator work with R22's appState as its declared replacement.

Seven tests lost their subject. Those whose only content was the shortcut's own
mechanics are deleted; the outcome-level ones are retargeted and keep asserting
what survives.

---------

Co-authored-by: agent <agent@local>

* fix(contracts): a falsely advertised element read fails as a contract bug

An owner whose facts advertised `readTextAtPoint` but whose interactor cannot
perform it was reported as `{ status: 'unreadable', reason: 'surface-not-readable' }`.
That put a contract violation inside the closed reason set that licenses falling
back to the captured tree, so `get text` answered from potentially stale snapshot
text precisely because the runtime lied about itself. ADR 0019 §2 requires the
mismatch to fail as `runtime-contract-invalid`; it now throws.

Removing the only producer of `surface-not-readable` made that reason dead: no
path can reach it, since an interactor that HAS the read maps a blank or absent
answer to `no-text-at-point` via `elementTextRead`. Dropped from the union, its
consumer switch arm, and both test lists. `classifiedFallbackReason`'s `never`
arm stays — it is what makes adding a reason a compile error rather than a
silent untyped fallback.

Deduplication found while auditing the change:

- `invalidRuntimeContract` was module-private in `platform-runtime.ts`. It now
  owns its own module so both runtime modules share one construction. It is
  deliberately not exported through the platform facade: that facade must stay
  exhaustive over its sources, which would make this a public symbol with no
  external consumer.
- The 8-field runner execution projection was written out three times
  (`snapshot-runtime-capture-input.ts`, `interaction-read.ts`,
  `screenshot-runtime.ts`). One `runtimeExecutionFromContext` now serves all
  three; `screenshotExecutionFromContext` keeps its name and delegates, since
  `ScreenshotRuntimeExecution` and `SnapshotRuntimeExecution` are the same type.
  Dropping a field here silently strips request id, log/trace paths, XCUITest
  overrides, or runner lease context — an operation that still answers but runs
  unconfigured, which is exactly the defect the wait unit hit as a P1.

Red before green: with the old guard restored the new regression fails with
"Missing expected rejection" — the call resolves instead of throwing, which is
the silent degradation it exists to forbid.

---------

Co-authored-by: agent <agent@local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant